The following project tries to replicate the back-end processes that well-known webpages in the League Of Legends community such as League Of Graphs and Lolalytics that serve different purposes each, one to show statistics regarding users and the other regarding statistics of champions & items.
The workflow is as follows:
API key setup
Obtaining usernames per tier and division
Obtaining latest matches of users
Obtaining Match Information (End_Game Results)
Obtaining Timeline Match Information (Game_Events)
To give a little bit of context, League of Legends is a multiplayer online battle arena (MOBA) game in which the player controls a character (“champion”) with a set of unique abilities from an isometric perspective. As of 2023, there are over 160 champions available to play.
Riot Games (the parent company), allow developers to access game data in a secure and reliable way through the Riot Developer Portal in which we can obtain all the endpoints available, detailed information about the data obtained among other inquiries developers may have.
The different objectives we propose among the project have a different flow in terms of endpoints as a way to use as many of the endpoints available. Although before going straight into the objectives and the diverse flows, first we need to set the API key and a common to call the endpoint.
The libraries that will be used across the project are the ones shown below.
library(httr)
library(jsonlite)
library(data.table)
library(stringr)
library(tidyverse)
library(knitr)
library(dplyr)
library(ggplot2)
library(gganimate)
library(transformr)
library(ggpubr)
library(magick)
library(ggplot2)
library(ggimage)
library(cowplot)
library(jpeg)
library(shiny)
library(httr)
library(jsonlite)
library(dplyr)
library(stringr)
library(lubridate)
library(ggplot2)
The API Key Setup and the main idea/way to access the data of the endpoints is based on the work done by Reddit user “WallabyKingdom” whose work can be seen (here)[https://rpubs.com/WallabyKingdom/riot-api]. The date of the work dates approximately three years ago, in which the endpoints of the Riot Developer Portal have changed and updated in recent years mainly going from the fourth version to the fifth one as can be seen in the names of the endpoints, as well as username names and ids have been unified across the different video games Riot Games own, information about this change can be seen (here)[https://darkintaqt.com/blog/ids] although through the work the process will be explained.
The following formula is the one mentioned above and the idea is to serve as a common point to obtain the data of the different endpoints. The main reason why the API Key is shown and not encoded in an .env environment is that it expires every 24 hours and needs to be updated through the Riot Developer Portal, which can be accessed by the link above, and to obtain a Personal API Key a user needs to be created.
There are two different types of API Keys which as stated in the Riot Developer Portal are the following:
Personal keys are meant for smaller-size, personal projects, such as testing the API, products for yourself and a small group, or for creating prototypes to later submit for a production key
Production keys are meant for larger-size, professional projects. For production keys, we need to see a functioning website/application or a prototype that demonstrates you’ll be able to fully execute on your use case.
The rate limit for a personal keys is by design very limited:
riot_api_fetching <- function(x) {
key <- "RGAPI-8e3a4174-deeb-4036-8625-7c8d6d7e042a"
url <- paste0(x, key)
json <- GET(url = url)
raw <- rawToChar(json$content)
fromJSON(raw)
}
delimiter <- "?api_key="
delimiter2 <- "&api_key="
Overall information about the different endpoints can be seen (here)[https://developer.riotgames.com/apis]. The workflow we did throughout the project, is to analyze how the endpoint works manually in the webpage, replicate for one instance in R, and then develop for a small set of data, and finally with a large size of data to ensure there are no errors.
It is important to notice that the current performance and availability of the endpoints can be checked (here)[https://developer.riotgames.com/api-status/]. Throughout the development of the project we have had issues with the service being down or new API keys not working as intended.
Although these issues persisted for several days, the issues are isolated, and the information obtained from matches or user accounts give no problems outside the ones fixed manually. That is to say, there may be problems regarding Response Errors although if they occur they appear globally rather than in specific matches or account usernames information. For that matter the errors encountered are regarding the data obtained not regarding the acquisition of the data.
Regarding the second issue with the renewal of API Keys, during the weekend of the 9th and 10th of March once an API Key expired the renovation did not work, in that instance (DarkIntaqt)[https://github.com/DarkIntaqt] the creator of (YearInLOL)[https://yearin.lol/] helped us by providing a working API Key during the time the issue was happening as can be seen (here)[https://github.com/RiotGames/developer-relations/issues/900].
In League Of Legends, there are ranked matches in which players compete within each other in a ranking. This ranking to show difference across players instead of using just a Number Ranking or “Elo” as Chess.com does. In this case players are separated between ranks and divisions, and from the “Elo” rating they place in one or another. In the following (section)[https://www.leagueofgraphs.com/rankings/rank-distribution] of the Leagueofgraphs webpage can be seen the distribution of players across ranks to visualize differences of skill level depending on the tier.
There are two ways in which we can obtain the information of the matches using the API, we either have to manually say the username(s) we want to analyze matches from, or we can indicate through one of the endpoints a tier and division to obtain usernames that belong to that rank.
The following code section will allow us to iteratively analyze all the ranks and divisions without having to change the final endpoint of the section as will be explained later.
It is important to notice that the following tiers are not all the tiers that exist as higher tiers that containing 0.5% of the player base have their own endpoint as they are the TOP 0.5% players in that region.
tier <- c("IRON", "BRONZE", "SILVER", "GOLD", "PLATINUM", "EMERALD", "DIAMOND")
division <- c("IV", "III", "II", "I")
combinations_df <- expand.grid(division, tier)
combinations_list <- list()
for (i in seq_len(nrow(combinations_df))) {
combinations_list[[i]] <- combinations_df[i, ]
}
The following call of the endpoint is done in this way:
accounts <- lapply(combinations_list, function(x) {
Sys.sleep(1.3)
division <- x[1, 1]
tier <- x[1, 2]
print(x)
return(riot_api_fetching(
paste0("https://euw1.api.riotgames.com/lol/league/v4/entries/RANKED_SOLO_5x5/", tier, "/", division, "?page=1", delimiter2)))
})
## Var1 Var2
## 1 IV IRON
## Var1 Var2
## 2 III IRON
## Var1 Var2
## 3 II IRON
## Var1 Var2
## 4 I IRON
## Var1 Var2
## 5 IV BRONZE
## Var1 Var2
## 6 III BRONZE
## Var1 Var2
## 7 II BRONZE
## Var1 Var2
## 8 I BRONZE
## Var1 Var2
## 9 IV SILVER
## Var1 Var2
## 10 III SILVER
## Var1 Var2
## 11 II SILVER
## Var1 Var2
## 12 I SILVER
## Var1 Var2
## 13 IV GOLD
## Var1 Var2
## 14 III GOLD
## Var1 Var2
## 15 II GOLD
## Var1 Var2
## 16 I GOLD
## Var1 Var2
## 17 IV PLATINUM
## Var1 Var2
## 18 III PLATINUM
## Var1 Var2
## 19 II PLATINUM
## Var1 Var2
## 20 I PLATINUM
## Var1 Var2
## 21 IV EMERALD
## Var1 Var2
## 22 III EMERALD
## Var1 Var2
## 23 II EMERALD
## Var1 Var2
## 24 I EMERALD
## Var1 Var2
## 25 IV DIAMOND
## Var1 Var2
## 26 III DIAMOND
## Var1 Var2
## 27 II DIAMOND
## Var1 Var2
## 28 I DIAMOND
In which we get the accounts that are in the first page of data available across the combination of tier and divisions possible, that is to say, Iron 1, Iron 2, Iron 3, etc…
As the endpoint gives us all the accounts for the first page of data available, we have to manually filter the amount we want per division, that is to say if we put 50 as the default value, we will 200 per rank (four divisions per rank) and a total of 1400 accounts. It is important to notice that in some cases the summonerName obtained may be empty (again, this is an error on how the data obtained is shown rather than obtaining the data) for that matter a simple filtering have been applied.
Throughout the projects as we were working with a huge dataset of IDs to obtain a considerable amount of matches, as we have to run the code through night as it took 5-6 hours per endpoint at some point due to the request limits as can be seen in the Sys.sleep which allow us to stop the code from running to ensure we are in the limits (if we were to not respect this, we would get an Http status error code implying we have no authorization or any issue is happening).
Although it is not shown, we analyzed if the empty usernames and errors were systematic or random in which we the result was that there errors were not systematic and there were no relationship regarding when they occurred. For that matter as the errors were not regarding obtaining the data and it was how the data was shown later as it gave us problems trying to join vectors, lists into a dataframe we had to manually modify and implement return(null) for certain conditions. In this endpoint we did not have to do nothing like as the only errors would be usernames being empty and it did not give any problem binding the rows.
accounts_per_divison <- 1 #change this number to decide how many players per division to be analyzed or get information from
for (i in seq_along(accounts)) {
accounts[[i]] <- accounts[[i]][seq_len(accounts_per_divison), ]
}
full_accounts <- do.call(bind_rows, accounts)
names <- str_remove_all(full_accounts$summonerName, " ")
head(full_accounts|> select(tier, rank, summonerName, leaguePoints, wins, losses), n = 5)
## tier rank summonerName leaguePoints wins losses
## 1 IRON IV Bernardo2002pt 33 1 5
## 2 IRON III mallet123 42 1 6
## 3 IRON II SOLIIIS 72 3 3
## 4 IRON I FlxShot 75 20 30
## 5 BRONZE IV Conquerorus 19 69 68
As we can observe full_accounts is a dataframe of 1400 observations(players) in which we have general information about tier, rank, how many wins, how many losses, the LeaguePoints, among others.
In this case we only need the summonerName to continue, for that matter we get the vector of names that we will use to obtain the IDs of their recent matches.
counter <- 0
add <- lapply(names, function(x) {
counter <<- counter + 1
if (nchar(x) == 0) {
return(NULL)
}
summoner <- riot_api_fetching(paste0("https://euw1.api.riotgames.com/lol/summoner/v4/summoners/by-name/", x, delimiter))
acc_id <- summoner$puuid
if (counter %% 10 == 0) {
print(paste(counter, "/", length(names), "@", format(Sys.time(), "%H:%M:%S")))
}
Sys.sleep(1.3)
return(acc_id)
})
## [1] "10 / 28 @ 19:12:16"
## [1] "20 / 28 @ 19:12:30"
As mentioned above, as we worked on a large list of names, we have the counter, and inside the anonymous function a print counter if divided by 10 gives a zero, to visualize how many names are remaining in terms of obtaining the data. For example if we had 1400 accounts if we have 50 per division, we would be shown this:
“10 / 1400 @ 18:58:47”
As a way to analyze when an error occured we used sink() to then filter the list so it contains the summonerName that gave an error, although it is important to notice that in some cases when we filtered the list to contain the error it did not give an error whereas if we had the entire list it did.
That is why we included the following in the API call so it gave no erros throughout the endpoint calling or later on joining the information obtained.
if (nchar(x) == 0) {
return(NULL)
}
As mentioned at the start of the project, recently the way the IDs work throughout RiotGames changed, for that matter the most important ID is the puuid which we obtained with the endpoint above and will be used to obtain the IDs of the matches that are only accessible using the puuid rather than the summonerName as the summonerName can be easily changed (withing paid ingame currency, or free ingame currency obtained) for that matter the puuid serves as an ID that does not change even if the summonerName changes.
The following section analyzes the cases in which the vector obtained in the previous endpoint is empty or not, as even though there were no problems obtaining the data, if we wanted to join the data using bind it would give us problems as some of them were empty.
non_empty <- vector()
for (i in seq_along(add)) {
non_empty[i] <- !(is.null(add[[i]]))
}
to_add <- do.call(rbind, add)
full_accounts2 <- full_accounts |> filter(non_empty) |> cbind(to_add) |> rename(acc_id = to_add)
head(full_accounts2 |> select(tier, rank, summonerName, leaguePoints, wins, losses)) #only some of the variables are shown for aesthetic purposes
## tier rank summonerName leaguePoints wins losses
## 1 IRON IV Bernardo2002pt 33 1 5
## 2 IRON III mallet123 42 1 6
## 3 IRON II SOLIIIS 72 3 3
## 4 IRON I FlxShot 75 20 30
## 5 BRONZE IV Conquerorus 19 69 68
## 6 BRONZE III DracVerde 34 4 2
Once we have filtered the accounts in which we do not have a puuid (empty vector), we join the accounts id (puuid) to the full_accounts dataframe obtained previously.
Now to obtain data about the recent matches of our players, we first have to get a list of the accounts ids (puuid) in order to call the endpoint.
acc_ids <- as.character(full_accounts2$acc_id)
The following code is to set the information we want to obtain from the endpoint, the query parameters (they are optional), the path parameter would be the puuid.
Queue equal to 420 implies we only want information about the Ranked matches. The json containing the information of the IDs of the queue can be seen (here)[https://static.developer.riotgames.com/docs/lol/queues.json]. Static information about champions, runes, items, queue ids, among other variables of interest outside of users and matches can be seen (here)[https://developer.riotgames.com/docs/lol] in detail.
queue <- "420"
start <- "0"
count <- "1" #change this depending on how many matches per player you want
The following API call will give us the matches ID used in a following step. As we want to obtain the match_id and the puuid from which we obtained the match_id from this anonymous function differs from the previous ones.
counter <- 0
matchestotal <- lapply(acc_ids, function(x) {
counter <<- counter + 1
matches <- riot_api_fetching(paste0("https://europe.api.riotgames.com/lol/match/v5/matches/by-puuid/", x, "/ids?queue=", queue, "&start=", start, "&count=", count, delimiter2))
if (is.null(matches)) {
return(NULL)
}
if (counter %% 10 == 0) {
print(paste(counter, "/", length(acc_ids), "@", format(Sys.time(), "%H:%M:%S")))
}
Sys.sleep(1.3)
return(list(acc_id = x, matches = matches))
})
## [1] "10 / 27 @ 19:12:56"
## [1] "20 / 27 @ 19:13:10"
matches_df <- do.call(rbind, lapply(matchestotal, as.data.frame))
head(matches_df)
## acc_id
## 1 Mrm8QzNrU32M1riE_NdI7YnGBVFlpmk4ljNVG4TCy7FLz18uSBBZ3WoBGGLqDkyavmtqJbP8WByTrQ
## 2 D836fEPffz_Hc1ncUCW7wdMDFEooolJe77I0_bzjIZHU2wW3li6mvUiqHzkYK1r7nAJqXRNg0KMOuw
## 3 NDrw3IrsOC8NZCXDksOmZo7rkQect02ZpKdeBVOmBFqusAdJylzpDsWu2j5rWxHXWVsawhumQtOHog
## 4 g7TcY-CmhAuTAId1qdYzZD-9wvpxrQm3lHzDZP1PxStJBGf0ftCxn7IaxuZqQULTnndCcbgxqpoDqg
## 5 iHld3hHaq5lI2OE-BCa00WaLR8PugExC-QKG7GIKxXPW7Jie-49gpI44WG5MJimvhLjIumjJVC3_kQ
## 6 STqMlLT_L5T6zXgynabxWN5rColYYyF4WOt7O-ljtJs7XPatMs3OmtUjsVXU9Pgp_pRdamP1Q8FVnQ
## matches
## 1 EUW1_6812069822
## 2 EUW1_6841412149
## 3 EUW1_6848006078
## 4 EUW1_6834494640
## 5 EUW1_6850585864
## 6 EUW1_6788788857
Again, in case we wanted to obtain more matches, we would have to change the count variable in the previous section. The following step will be to obtain the information about each match we want to analyze. The problem this section gave us was regarding obtaining the matches_id and the corresponding puuid. The importance of having the puuid is to show which tier and division this match originally came from as due to having ten players in each match, they do not have all the same rank. LeagueOfLegends uses an “invisible” rank called MMR in which although their visual rank do not match, they are placed in the same match as they are close to in terms of their “invisible” rank. That is to say, if a “new” player which is someone that has previously played or is using a secondary account starts playing the game is able to recognize him and put him against higher rank players if the game considers his abilities to correspond that rank, although his rank at first does not show that the game internally knows and places him in “higher rank matches”.
merged_data <- merge(matches_df, full_accounts2, by = "acc_id") |> select(matches, tier, rank)
matchestotallist <- matches_df$matches
First we have merged_data in which we have the matches_id, the tier and rank their correspond to. The following step is to obtain a list of all the matches we want to analyze in order to go through them in the following endpoint.
counter <- 0
matchestotal <- lapply(matchestotallist, function(x) {
counter<<- counter + 1
if (nchar(x) == 0) {
return(NULL)
}
apibranch <- riot_api_fetching(paste0("https://europe.api.riotgames.com/lol/match/v5/matches/", x, delimiter))
if (counter %% 10 == 0) {
print(paste(counter, "/", length(matchestotallist), "@", format(Sys.time(), "%H:%M:%S")))
}
if (!is.null(apibranch$info$gameDuration) && apibranch$info$gameDuration != 0) {
infomatch <- apibranch$info$participants
parts <- apibranch$metadata$participants
bind1 <- cbind(infomatch,parts)
duration <- apibranch$info$gameDuration
bind2 <- cbind(duration, bind1)
euwmatch <- apibranch$metadata$matchId
bind3 <- cbind(euwmatch,bind2)
Sys.sleep(1.4)
return(bind3)
} else {
return(NULL)
}
})
## [1] "10 / 27 @ 19:13:36"
## [1] "20 / 27 @ 19:13:52"
matchestotal_df <- bind_rows(matchestotal)
head(matchestotal_df |> select(euwmatch, duration, teamPosition, summonerName, teamId, win), n =10) #only some of the total variables were shown for aesthetic purposes
## euwmatch duration teamPosition summonerName teamId win
## 1 EUW1_6812069822 1563 TOP 4damg 100 TRUE
## 2 EUW1_6812069822 1563 JUNGLE DouXi 100 TRUE
## 3 EUW1_6812069822 1563 BOTTOM G2VSandia 100 TRUE
## 4 EUW1_6812069822 1563 MIDDLE SoyTheBlak 100 TRUE
## 5 EUW1_6812069822 1563 UTILITY CrisLife 100 TRUE
## 6 EUW1_6812069822 1563 TOP PPARDZ 200 FALSE
## 7 EUW1_6812069822 1563 JUNGLE Bernardo2002pt 200 FALSE
## 8 EUW1_6812069822 1563 MIDDLE Kaayn Bot 200 FALSE
## 9 EUW1_6812069822 1563 BOTTOM Vallerde 200 FALSE
## 10 EUW1_6812069822 1563 UTILITY Vullpe 200 FALSE
This section had more errors as can be seen in the return(NULL) and the !is.null used in the function, and we have to manually call the different parts of the JSON archive obtained in order to get a clean dataframe that contains the matchID, gameDuration, the participants and the overall information of the match. The information of the ID, duration and participants is saved in the different vectors in the JSON that is why we have to manually add them to a dataframe we created.
This dataframe can be joined to the merged_df we had with the tier and rank as a way to analyze differences across divisions, win rate of items and champions across divisions, as well as performing classification models to understand which variables matter the most in terms of getting the victory!
Although this endpoint was used to get data about matches, there is another endpoint that allow us to get information throughout the duration of the game, that is to say obtain information regarding the different events that occur throughout a match (kills, towers taken, objectives taken, wards placed, among others).
Another one of the objectives we had is analyzing a heat map in terms of where deaths occur across the map. It is important to notice that to analyze this, we need to have a large sample of matches in order to be able to represent accordingly the data.
counter <- 0
timeline <- lapply(matchestotallist, function(x) {
counter <<- counter + 1
timelineapi <- riot_api_fetching(paste0("https://europe.api.riotgames.com/lol/match/v5/matches/", x,"/timeline", delimiter))
if (is.null(timelineapi$info$frames$events)) {
return(NULL)
}
if (timelineapi$info$gameId == 0) {
return(NULL)
}
# Print progress message every 10th iteration
if (counter %% 10 == 0) {
print(paste(counter, "/", length(matchestotallist), "@", format(Sys.time(), "%H:%M:%S")))
}
frame_df <- timelineapi$info$frames
gameId <- timelineapi$info$gameId
events <- frame_df |>
pull(events) |>
map_df(as.data.frame) |>
mutate(gameId = timelineapi$info$gameId)
Sys.sleep(1.3)
return(events)
})
## [1] "10 / 27 @ 19:14:23"
## [1] "20 / 27 @ 19:14:43"
matchestimeline <- bind_rows(timeline)
In this case more errors before obtaining all the data occurred, in this case we had to remove “info_frames_events” that were null and “info_gameId == 0”. The symbol has been used as dollar sign in order to not have problems of syntax in the html file.
One of the main issues we had is how R works as the data obtained at first looked fine until what we got converting the data to df at first was that the first column each row was a dataframe.
We had problems trying to get the data as a dataframe for all the matches as each row in that column the dataframes did not have the same columns shape information and gave a lot of problems until we found in a korean forum a user that in Python was able to get the data simple as that. Although in Python was easier was getting the data of that column in R implied “losing” the other columns in the main dataframe was we do not need them for our project and research we did not mind at all, as we saw on the internet is that R considers everything a vector and gave a lot of problems trying to fix that. Although as we only wanted to get the type of event (in this case, kill event) and it x-y coordinates the rest did not matter. Mainly was information about which champion did the kill, etc…
The way we fixed it was using pull as explained in the korean personal blog of @chaechae user.
The continuation of this section will not be replicated in this Rmd as in order to work we need a large dataset in order to plot correctly the density map. We have already ran it with 15000 matches and it took 7 hours of running the code to obtain it and the results will be shown in their own section and the class presentation.
mapacalor <- matchestimeline |>
filter(type == "CHAMPION_KILL") |>
select(timestamp, position, gameId)
head(mapacalor)
As we can observe in this case the dataframe is really simple, we observe the timestamp (to get the minutes we have to divide by 60000), the position and gameId in case we wanted to filter the data by tier and rank we would do it by joining this df with the one that has that information.
mapacalor1 <- mapacalor |>
mutate(minuto = round(timestamp/60000
))
class(mapacalor1$minuto)
breaks <- c(seq(0, 60, by = 5))
labels <- seq(5, 60, by = 5)
mapacalor1$minute_interval <- cut(mapacalor1$minuto, breaks = breaks, labels = labels)
mapacalor1$minute_interval[is.na(mapacalor1$minute_interval)] <- 60
mapacalor1$minute_interval <- as.numeric(as.character(mapacalor1$minute_interval))
mapacalor1$minute_interval <- (mapacalor1$minute_interval *5)
img <- readJPEG("riotmap2.jpg")
ggplot(mapacalor1, aes(mapacalor1$position$x, y = mapacalor1$position$y))+
background_image(img) +
stat_density_2d(
geom = "raster",
aes(fill = after_stat(density)),
contour = FALSE,
) +
scale_fill_gradient(low = "transparent", high = "red") +
theme_void() +
theme(legend.position = "none") + transition_time(minute_interval) +
labs(title = "Minute: {frame_time}")
plot_heatmap <- function(minute_interval) {
ggplot(mapacalor1 |> filter(minute_interval == minute_interval),
aes(mapacalor1$position$x, y = mapacalor1$position$y)) +
background_image(img) +
stat_density_2d(
geom = "raster",
aes(fill = after_stat(density)),
contour = FALSE
) +
scale_fill_gradient(low = "transparent", high = "red") +
theme_void() +
theme(legend.position = "none") +
labs(title = paste("Minute Interval:", minute_interval))
}
plot_heatmap(5)
plot_heatmap(25)
table(mapacalor1$minute_interval)
mapacalor2 <- mapacalor1 |> filter(
minute_interval < 45
)
ggplot(mapacalor2, aes(mapacalor2$position$x, y = mapacalor2$position$y))+
background_image(img) +
stat_density_2d(
geom = "raster",
aes(fill = after_stat(density)),
contour = FALSE,
) +
scale_fill_gradient(low = "transparent", high = "red") +
theme_void() +
theme(legend.position = "none") + transition_time(minute_interval) +
labs(title = "Minute: {frame_time}")
ggplot(mapacalor2, aes(mapacalor2$position$x, y = mapacalor2$position$y))+
background_image(img) +
stat_density_2d(
geom = "tile",
aes(fill = after_stat(density)),
contour = FALSE,
) +
scale_fill_gradient(low = "transparent", high = "red") +
theme_void() +
theme(legend.position = "none") + facet_wrap(~minute_interval)
Although the code is not ran in this case, we will explain it briefly. The idea is to plot a density plot that shows the evolution throughout the game, one of the main issues we found is that the stat_density_2d in ggplot2 with gganimate does not take into account the different minute intervals as “unique” in the sense that the strength of the density is taken in general rather than in those specific interval times, for that matter, if we plot the map as a background the color starts to disappear in later stages of the game as there are fewer deaths and is comparing to the general density at any point in time. That is to say, even though there are 100 deaths in minute 40, for example, the strength of the density shown is taking into account the 10000 deaths for that matter the 100 deaths will show really small or not nearly visible in the red gradient.
As a way to fight this issue that happens in ggplot2 more specifically in stat_density_2d trying to do facet_wrap does not solve the issue, but filtering the data and then plotting as we did the function and then showing which minute interval we want it kinda solves that problem although we lose the animation. Another fix we did is removing the background map and being white as a way to spot the density when it is low in later minutes of the game.